/*
 * Copyright (C) 2020 Square, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import Foundation

/**
 A class responsible for turning an in-memory struct generated by the Wire
 code generator into serialized bytes in the protocol buffer format that
 represent that struct.

 General usage will look something like:
 ```
 let encoder = ProtoEncoder()
 let data = try encoder.encode(generatedMessageInstance)
 ```
 */
public final class ProtoEncoder {

    // MARK: -

    public enum Error: Swift.Error, LocalizedError {

        case stringNotConvertibleToUTF8(String)

        var localizedDescription: String {
            switch self {
            case let .stringNotConvertibleToUTF8(string):
                return "The string \"\(string)\" could not be converted to UTF8."
            }
        }

    }

    // MARK: -

    /// The formatting of the output binary data.
    public struct OutputFormatting : OptionSet {

        /// The format's numerical value.
        public let rawValue: UInt

        /// Creates an OutputFormatting value with the given raw value.
        public init(rawValue: UInt) {
            self.rawValue = rawValue
        }

        /**
         Produce serialized data with map keys sorted in comparable order.
         This is useful for creating deterministic data output but incurs a minor
         performance penalty and is not usually necessary in production use cases.
         */
        public static let sortedKeys: OutputFormatting = .init(rawValue: 1 << 1)

    }

    // MARK: - Properties

    public var outputFormatting: OutputFormatting = []

    // MARK: - Initialization

    public init() {}

    // MARK: - Public Methods

    /** Encode a `ProtoEncodable` field into raw data */
    public func encode<T: ProtoEncodable>(_ value: T) throws -> Data {
        try encodeWithWriter(value, syntax: T.self.protoSyntax ?? .proto2) { writer in
            try value.encode(to: writer)
        }
    }

    // MARK: - Internal Methods

    /** Encode a tagged `ProtoEncodable` field into raw data */
    internal func encode<T: ProtoEncodable>(tag: UInt32, value: T) throws -> Data {
        try encodeWithWriter(value, syntax: T.self.protoSyntax ?? .proto2) { writer in
            try writer.encode(tag: tag, value: value)
        }
    }

    /** Encode a tagged `ProtoEnum` field into raw data */
    internal func encode<T: ProtoEnum>(tag: UInt32, value: T) throws -> Data where T: RawRepresentable<Int32> {
        try encodeWithWriter(value, syntax: T.self.protoSyntax ?? .proto2) { writer in
            try writer.encode(tag: tag, value: value)
        }
    }

    /** Encode a tagged `int32`, `sfixed32`, or `sint32` field into raw data */
    internal func encode(tag: UInt32, value: Int32, encoding: ProtoIntEncoding) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value, encoding: encoding)
        }
    }

    /** Encode a tagged `int64`, `sfixed64`, or `sint64` field into raw data */
    internal func encode(tag: UInt32, value: Int64, encoding: ProtoIntEncoding) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value, encoding: encoding)
        }
    }

    /** Encode a tagged `fixed32` or `uint32` field into raw data */
    internal func encode(tag: UInt32, value: UInt32, encoding: ProtoIntEncoding = .variable) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value, encoding: encoding)
        }
    }

    /** Encode a tagged `fixed64` or `uint64` field into raw data */
    internal func encode(tag: UInt32, value: UInt64, encoding: ProtoIntEncoding = .variable) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value, encoding: encoding)
        }
    }

    /** Encode a repeated tagged `fixed64` or `uint64` field into raw data */
    internal func encode(tag: UInt32, value: [UInt64], encoding: ProtoIntEncoding = .variable) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value, encoding: encoding)
        }
    }

    /** Encode a repeated tagged `fixed32` or `uint32` field into raw data */
    internal func encode(tag: UInt32, value: [UInt32], encoding: ProtoIntEncoding = .variable) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value, encoding: encoding)
        }
    }

    /** Encode a repeated tagged `fixed64` or `int64` field into raw data */
    internal func encode(tag: UInt32, value: [Int64], encoding: ProtoIntEncoding = .variable) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value, encoding: encoding)
        }
    }

    /** Encode a repeated tagged `fixed32` or `int32` field into raw data */
    internal func encode(tag: UInt32, value: [Int32], encoding: ProtoIntEncoding = .variable) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value, encoding: encoding)
        }
    }

    /** Encode a repeated tagged `bool` field into raw data */
    internal func encode(tag: UInt32, value: [Bool]) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value)
        }
    }

    /** Encode a repeated tagged `float` field into raw data */
    internal func encode(tag: UInt32, value: [Float]) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value)
        }
    }

    /** Encode a repeated tagged `double` field into raw data */
    internal func encode(tag: UInt32, value: [Double]) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value)
        }
    }

    /** Encode a repeated tagged `string` field into raw data */
    internal func encode(tag: UInt32, value: [String]) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value)
        }
    }

    /** Encode a repeated tagged `bytes` field into raw data */
    internal func encode(tag: UInt32, value: [Data]) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value)
        }
    }

    /** Encode a repeated tagged `ProtoEncodable` field into raw data */
    internal func encode<T: ProtoEncodable>(tag: UInt32, value: [T]) throws -> Data {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value)
        }
    }

    /** Encode a repeated tagged `ProtoEnum` field into raw data */
    internal func encode<T: ProtoEnum>(tag: UInt32, value: [T]) throws -> Data where T: RawRepresentable<Int32> {
        try encodeWithWriter(value, syntax: .proto2) { writer in
            try writer.encode(tag: tag, value: value)
        }
    }

    // MARK: - Private Methods

    private func encodeWithWriter<T>(
        _ value: T,
        syntax: ProtoSyntax,
        encoder: (ProtoWriter) throws -> Void
    ) throws -> Data {
        // Use the size of the struct as an initial estimate for the space needed.
        let structSize = MemoryLayout.size(ofValue: value)

        let writer = ProtoWriter(
            data: .init(capacity: structSize),
            outputFormatting: [],
            rootMessageProtoSyntax: syntax
        )
        writer.outputFormatting = outputFormatting
        try encoder(writer)

        return Data(writer.buffer, copyBytes: false)
    }
}
